'''Tools for merging bibliographys [Currently broken!]

@status:
    The code in this module was poorly thought out and is of limited
    usefulness.

@copyright: (c) 2009-2010, Benjamin Kalish

@license:
    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 2 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful, but
    WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    General Public License for more details.
@deprecated:
    This entire module has been replaced by L{new_merge_tools}.
    '''

import copy
import hashlib
import json
import os
import re
import string

from bkn.bibtex.bibliography import *

_MERGE_WARNING_TEMPLATE = '{0.parent_bib.name:10}:{0.citekey:20} {1}'
'''A format string for use in reporting errors when merging.'''
_CORRECTION_WARNING_TEMPLATE = \
    'Warning! Applying correction to {0.citekey} which has changed'
'''A format string for use in reporting errors when applying corrections.'''

def title_hash(entry):
    '''The default hash_function for use with L{merge_bibliographies()}.
    
    @return:
        Always returns the same value for entries with strings which are
        considered sufficiently similar. If the entry does not have a
        title field then a cryptographic hash of the entry is returned.
        This makes it improable that two distinct entries without titles
        will return the same value.

    @returntype: string

    @warning:
        This function ignores mathematics completely, so that calling
        this method on entries with the titles::
        
            New ideas about a classic formula: $a^2 + b^2 = c^2$
            New ideas about a classic formula: $E = m c^2$
            New ideas about a classic formula: $Spam + Eggs$
        
        will return the the same value.

    @param entry: the entry to be considered
    @type entry: L{BibEntry}
    '''
    normalized_title = _clean_title(entry['title'])
    if normalized_title != '':
        return normalized_title
    else:
        return hashlib.sha224(entry.to_bibtex()).hexdigest()


def merge_bibliographies(
        bibliographies,
        hash_function=title_hash,
        whitelist = None):
    '''Merges a list of bibliographies.
    
    hash_function, if supplied, should be a function which taking a
    L{BibEntry} as an argument and must always return the same,
    hashable, value for entries which are to be merged, and distinct
    values for entries which should not be merged.
    
    This function adds a couple of new attributes to the entries in the
    merged bibliography:
    
        1. C{merge_sources} - a string listing the names of the bibs
        which were merged to create this entry.
        
        2. C{merge_warnings} - a string giving any warnings about conflicting
        values for this entry.
    
    @param bibliographies: the bibliographies to be merged
    @type bibliographies: list of L{Bibliography} objects.

    @param hash_function: a hash function used to identify duplicate
        records. 
    @type hash_function: function
    
    @param whitelist: if not None, then only the fields named in this
       list will be preserved during the merge.
    @type whitelist: list of strings
    
    @returntype: L{Bibliography}
    @return: the merged bibliography. 
    '''
    # we don't want to alter the passed bibliographies
    bib_list = copy.deepcopy(bibliographies)
    
    # normalize field values and apply filters
    for bib in bib_list:
        for entry in bib.entries:
           _normalize_fields(entry)
           entry.bibtype = entry.bibtype.lower()
           if whitelist is not None:
               for field in entry.field_names():
                   if field not in whitelist: del entry[field]
    
    
    
    # create index
    index = {}
    for bib in bib_list:
        for bib_entry in bib.entries:
            key = hash_function(bib_entry)
            if index.has_key(key):
                index[key] += [bib_entry]
            else:
                index[key] = [bib_entry]
    index_keys = index.keys()
    index_keys.sort()
        
    # merge bibliographies
    new_bib = Bibliography()
    for i in range(len(index_keys)):
        key = index_keys[i]
        entries = index[key]
        new_entry = BibEntry(citekey=str(i))
        
        new_entry.merge_sources = ', '.join(
            [e.parent_bib.name + ':' + e.citekey for e in entries]
            )
        
        new_entry.bibtype = entries[0].bibtype
                    
        list_of_field_name_lists = [entry.field_names() for entry in entries]
        field_names = _remove_dups(_sum_lists(list_of_field_name_lists))
        for f in field_names:
            for e in entries:
                if e[f] != '':
                    new_entry[f] = e[f]
                    break
        
        # add warnings
        warnings = []
        bibtypes = [e.bibtype for e in entries]
        if _values_differ(bibtypes):
            warnings.append(
              ('differing values for bibtype:\n  ' +
                '\n  '.join([
                  _MERGE_WARNING_TEMPLATE.format(e,e.bibtype)
                  for e in entries
                  ])
                )
              )
        for f in field_names:
            values = [e[f] for e in entries if not blank(e[f])]
            if _values_differ(values):
                warnings.append(
                  ('differing values for ' + f + ':\n  ' +
                    '\n  '.join([
                      _MERGE_WARNING_TEMPLATE.format(e,e[f])
                      for e in entries
                      ])
                    )
                  )

        new_entry.merge_warnings = '\n'.join(warnings)
        new_bib.add_entry(new_entry)    
    return new_bib

def persitent_keys_from_file(
        bib,
        filename,
        hash_function=title_hash,
        prefix=None):
    '''Assigns citekeys from the specified file.
    
    Assigns citekeys from the specified file, using C{hash_function} to
    identify which citekey to assign to which BibEntry.
    
    Entries with no corresponding citekey in the file will be a
    numerical citekey, whose value will be added to the file.
    Values for new citekeys begin at one more than the number of
    citekyes in the file.
    
    If the optional prefix is supplied, it will be used before any new
    numerical citekeys.
        
    @attention:
        This function will only assign unique citekeys if the
        C{hash_function} gives a unique value for each entry in the
        bibliography.
    @warning:
        This function assumes that numerical keys greater than the number
        of keys already assigned have not yet been used. Care should be
        taken when editing the citekey file.
    @param bib: the bibliography whose citekeys should be changed
    @type bib: L{Bibliography}
    @param filename: a path to the citekey file
    @type filename: string
    @param hash_function: a hash function, as in L{merge_bibliographies()}
    @type hash_function: function
    @param prefix: a prefix to use as the start of new citekeys
    @type prefix: string
    @returntype: None
    '''
    if prefix is None: prefix = ''
    
    if os.path.exists(filename):
        with open(filename) as file:
            dict = json.load(file)
    else:
        dict = {}
    
    new_citekey = len(dict)
    for entry in bib.entries:
        key = hash_function(entry)
        if not dict.has_key(key):
            dict[key] = prefix + str(new_citekey)
            new_citekey += 1
        entry.citekey = dict[key]
    
    with open(filename, 'w') as file:
        json.dump(dict, file)

def write_correctable_bibtex(bibliography, filename):
    '''Writes a bibliography to a BibTeX file with checksums to be used
    in conjunction with L{apply_corrections()}.
    
    write_correctable_bibtex produces a BibTeX file which has been
    enchanced in the following ways:
    
        1. Each entry is given a checksum field. This checksum is
        required by L{apply_corrections()} in order to produce warnings
        when a correction is applied to an entry which has changed.
        
        2. BibTeX warnings are added in comments before each entry.

        3. If the bibliography was generated using
        L{merge_bibliographies} then all warnings generated during the
        merge operation are included as comments before each entry.
        
        4. If the bibliography was generated using
        L{merge_bibliographies} then a list of the sources for each
        entry is listed in a comment before each entry. (References to
        entries are refered to by the name of the bib followed by a
        colon followed by the citekey in that bib.)
            
    @param bibliography: a merged bibliography (it is an error to pass an
        unmerged bibliography)
    @type bibliography: L{Bibliography}
    @param filename: the name of the file to be created
    @type filename: string
    @returntype: None
    '''
    # TO DO: check for unmerged bib and throw appropriate error
    bib = copy.deepcopy(bibliography)
    bib.add_bibtex_output()
    with open(filename, 'w') as file:
        for e in bib.entries:
            e['check_sum'] = hashlib.md5(e.to_bibtex()).hexdigest()
            if hasattr(e, 'merge_sources'):
                file.write(e.citekey + ' - sources: ' + e.merge_sources + '\n')
            if hasattr(e, 'merge_warnings'):
                if not blank(e.merge_warnings):
                    file.write(e.merge_warnings + '\n')
            if len(e.bibtex_warnings) > 0:
                file.write('\n'.join(
                    ['BibTeX warning: ' + w for w in e.bibtex_warnings]
                    ) +
                '\n')
            file.write('\n' + e.to_bibtex() + '\n\n')

def apply_corrections(bibliography, filename):
    '''Returns a bibliography with corrections taken from a file.
    
    Care must be taken when applying corrections to bibliographic data
    from a source that may change. This function reads an edited version
    of the bibliography generated using L{write_correctable_bibtex} and
    compares the checksums in that file to checksums generated from the
    supllied bibliography. A warning is generated for each correction
    which is applied to an entry which has changed.

    Corrections are applied as follows:
    
        - To delete an entry, change the entry type to 'delete', so::
        
            @delete{key1,
              author = {Charles Kingsley},
              check_sum = {3f9c929a1e9d2f179c1ef8fca02ceed4},
              title = {The Water Babies},
              year = {1916},
            }
        
        Will delete the entry with citekey 'key1' and will issues a warning
        if that entry has changed.
        
        - Entries may be edited simply by including the edited entry,
        with the caveat that entries are identified by citekey, so
        citekeys may not be changed.
        
        - To add a new entry, simply include the new entry, using a key
        which will not collide with any key in source bibliography.
        
    @param bibliography: a bibliography
    @type bibliography: L{Bibliography}
    @param filename: the filename of a BibTeX file from which the
        corrections should be read
    @type filename: string
    @returntype: None
    '''
    # TO DO: add better error handling
    # TO DO: write tests
    new_bib = copy.deepcopy(bibliography)
    corrections = Bibliography(file=filename)
    
    # index new_bib by citekey
    index = {}
    for entry in new_bib.entries:
        index[entry.citekey] = entry
        
    for correction in corrections.entries:
        if correction.citekey in index.keys():
            old_entry = index[correction.citekey]
            check_sum = hashlib.md5(old_entry.to_bibtex()).hexdigest()
            if check_sum != correction['check_sum']:
                print _CORRECTION_WARNING_TEMPLATE.format(correction)
            new_bib.entries.remove(old_entry)
        if correction.bibtype != 'delete':
            new_bib.add_entry(correction)
    
    return new_bib
            
            
            
   
# PRIVATE FUNCTIONS 

def _values_differ(list_):
    '''Returns true if values in list differ.'''
    if len(list_) == 0: return False
    last = list_[0]
    for e in list_:
        if last != e: return True
        last = e
    return False

def _clean_title(t):
    '''Cleans a title string for indexing.'''
    t = t.split('(')[0]
    t = t.split('In ')[0]
    t = t.split('In: ')[0]
    t = t.split('invited paper')[0]
    t = t.lower()
    
    # remove unwanted symbols; math will be replaced by '**'
    #
    u = ''
    prev_letter = ''
    math_mode = False
    for letter in t:
        if letter == '$' and prev_letter != '\\':
            math_mode = not math_mode
            u += '*'
        if not math_mode:
            if letter in string.lowercase + ' ':
                u += letter
        prev_letter = letter
    
    # collapse consecutive whitespace and return
    #
    return ' '.join(u.split())

_PAGE_RANGE_PATTERN = re.compile(r'(\d)-(\d)')
'''Matches a set of digits seperated by hyphens. Used in _normalize_fields.'''
 
def _normalize_fields(bib_entry):
    for key in bib_entry.fields.keys():
        bib_entry[key] = ' '.join(bib_entry[key].split())
    bib_entry['pages'] = _PAGE_RANGE_PATTERN.sub(r'\1--\2', bib_entry['pages'])

def _sum_lists(list_of_lists):
    '''Returns the sum of the lists in list.'''
    return reduce(lambda a,b: a+b, list_of_lists, [])

def _remove_dups(list_):
    '''Rmoves duplicates from a list.'''
    return list(set(list_))


